Skip to content

feat(js): add extra account metas helper for transfer hooks - #1351

Open
gitteri wants to merge 13 commits into
solana-program:mainfrom
gitteri:ilangitter/exo-346-add-helper-to-token-2022-for-transfer-hooks
Open

feat(js): add extra account metas helper for transfer hooks#1351
gitteri wants to merge 13 commits into
solana-program:mainfrom
gitteri:ilangitter/exo-346-add-helper-to-token-2022-for-transfer-hooks

Conversation

@gitteri

@gitteri gitteri commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Ports the js-legacy transfer-hook "extra account metas" helper to the new @solana-program/token-2022 js client (clients/js)
  • Adds findExtraAccountMetaListPda to derive the transfer hook validation account PDA
  • Adds getExtraAccountMetas to parse the extra account meta list out of raw validation account data

gitteri added 7 commits July 27, 2026 09:25
Ports the js-legacy transfer-hook "extra account metas" helper to the
new @solana-program/token-2022 client. This first increment covers the
PDA derivation and raw validation-account parsing (findExtraAccountMetaListPda,
getExtraAccountMetas); seed/pubkey-data resolution and instruction-building
helpers land in a follow-up.

Refs: EXO-346
Port unpackSeeds and unpackPubkeyData from clients/js-legacy to the
codama-generated client, adapted to @solana/kit (Address, fetchEncodedAccount
instead of Connection/PublicKey). Adds unit tests backed by a LiteSVM rpc
client for the account-data-fetching branches.

Refs: EXO-346
Fixes oxfmt line-width violations and oxlint return-await errors in
unpackFirstSeed/unpackPubkeyData flagged by CI on the prior commit.

Refs: EXO-346
Ports resolveExtraAccountMeta from the js-legacy transfer hook client,
turning a single ExtraAccountMeta plus the previously resolved
accounts into a literal pubkey, a pubkey-data lookup, or a PDA
derived off the transfer hook program or a previously resolved
account.

Refs: EXO-346
Adds deEscalateAccountMeta (AccountRole-based port of legacy's
isSigner/isWritable de-escalation) and resolveExtraAccountMetasForExecute,
which resolves a transfer hook's full extra-account list for an Execute CPI.
Mirrors legacy's addExtraAccountMetasForExecute but returns the additional
AccountMetas for the caller to append, since kit instructions are immutable.

Refs: EXO-346
Covers resolveExtraAccountMetasForExecute against a validation account
mixing literal pubkeys, a PDA seeded off base account keys, a PDA
seeded off previously-resolved extras (chaining), and a PDA seeded by
a literal + instruction-data seed.

Refs: EXO-346
@gitteri
gitteri marked this pull request as ready for review July 27, 2026 17:38
composes transferChecked with transfer hook extra account resolution: fetches the mint to discover the hook program, resolves and de-escalates the extra accounts, and appends them. returns a plain transferChecked when the mint has no hook
@lorisleiva
lorisleiva self-requested a review July 28, 2026 08:06
@lorisleiva

Copy link
Copy Markdown
Member

@trevor-cortex

@trevor-cortex trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Ports the js-legacy transfer-hook "extra account metas" helper to @solana-program/token-2022. Adds:

  • findExtraAccountMetaListPda — derives the validation account PDA.
  • getExtraAccountMetas / codecs — parses the raw validation account into a list of ExtraAccountMetas.
  • unpackSeeds, unpackPubkeyData, resolveExtraAccountMeta — resolve one meta into a concrete Address (literal, pubkey-data, or PDA off the hook program / a previously resolved account), matching the transfer-hook interface's discriminators (0 literal, 1 program PDA, 2 pubkey-data, 1<<7 + i account-index PDA).
  • deEscalateAccountMeta — kit-native port of legacy's de-escalation via mergeRoles + downgradeRoleToNonSigner/downgradeRoleToReadonly.
  • resolveExtraAccountMetasForExecute — returns the extras + hook program + validation account, ready to append.
  • createTransferCheckedWithTransferHookInstruction — end-to-end wrapper that fetches the mint, checks for the TransferHook extension, and appends the resolved extras to a transferChecked.

The test suite is thorough: unit tests per resolver variant, error-path tests for each bounds check, and integration tests using @solana/kit-plugin-litesvm for the account-data variants and the end-to-end wrapper.

Key things to watch out for

1. Base-meta roles used for de-escalation look wrong (main concern). In resolveExtraAccountMetasForExecute, baseMetas is built with every base account (source, mint, destination, owner, validationState) forced to AccountRole.READONLY. That list is then used as the reference for deEscalateAccountMeta. Since the outer transferChecked actually declares source and destination as WRITABLE and authority as READONLY_SIGNER, using all-READONLY here will over-de-escalate any extra that legitimately duplicates one of those addresses — e.g. an extra config claiming WRITABLE for the source address gets forced to READONLY, even though the outer instruction already declares source as writable.

The legacy js reference passes the actual TransactionInstruction.keys (with real per-account booleans) into deEscalateAccountMeta, so the highest-role reference reflects the real declared privileges. Worth mirroring that here — the deEscalates an extra account duplicating the owner test currently passes only because owner happens to be READONLY in this all-READONLY model; with correct roles (authority = READONLY_SIGNER), the expected downgrade would be to READONLY_SIGNER, not READONLY.

2. Validation-account prefix isn't verified. getExtraAccountMetas unconditionally skips the first 12 bytes and decodes the rest — no check on the TlvState-style discriminator or the length prefix. Legacy js does the same, so this is arguably an intentional port, but it means malformed / wrong-owner account data will silently decode into garbage ExtraAccountMetas rather than throw. Consider at least validating the u32 length against the remaining buffer, or documenting the assumption.

3. Custom programAddress + fetchMint. createTransferCheckedWithTransferHookInstruction accepts a custom programAddress (defaulting to Token-2022), but always calls fetchMint from ./generated, which is the Token-2022 mint decoder. If someone passes classic Token here, the mint fetch will misdecode (or the TransferHook extension lookup will simply return nothing, which is the more likely path). Worth a doc note that the helper is meaningful only for Token-2022 mints, or drop the programAddress param.

Notes for subsequent reviewers

  • Discriminator ranges in resolveExtraAccountMeta: 0 (literal) and 2 (pubkey-data) are handled first, 1 (program-PDA) is special-cased in the PDA branch, and everything else falls through to discriminator - (1<<7) with a bounds check. That means discriminators 3..127 produce a negative accountIndex and hit the "out-of-bounds account index" error — technically correct but the error message is a bit misleading for that range. Minor.
  • unpackSeeds loop guard is i < 32, matching the on-chain 32-byte packed-seeds limit. All sub-parsers return packedLength >= 2, so no infinite-loop risk.
  • The imports look fine but fetchMint, getTransferCheckedInstruction, TOKEN_2022_PROGRAM_ADDRESS, Extension, and getMintEncoder all rely on being re-exported from ./generated and ./index — worth a pnpm build && pnpm test locally to confirm.
  • Consider whether getExtraAccountMetaCodec, getExtraAccountMetaListCodec, and the ExtraAccountMetaList/ExtraAccountMeta/ResolvedExtraAccountMeta/ExtraAccountMetaListSeeds types should all be part of the public surface — some of these (e.g. the internal list codec) currently aren't exported, which is probably fine, but the mix of export function get…Codec vs the un-exported list codec looks slightly inconsistent.

Comment thread clients/js/src/transferHookExtraAccountMetas.ts
Comment on lines +91 to +95
/**
* Unpacks a transfer hook validation account and parses its data into the list of
* `ExtraAccountMeta`s configured for the mint.
*/
export function getExtraAccountMetas(data: ReadonlyUint8Array): ExtraAccountMeta[] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This trusts the first 12 bytes are always [u64 discriminator][u32 length] and never validates the u32 length against the remaining buffer. If callers pass an account they don't fully own (wrong owner, uninitialized, etc.), getExtraAccountMetaListCodec will silently decode garbage into ExtraAccountMeta[] and only fail later during resolution.

Worth at least validating that data.length >= EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE and ideally that the encoded length matches, or documenting the assumption clearly in the JSDoc. Legacy does the same laissez-faire parse, so this may be intentional — flagging for a conscious call.

Comment on lines +484 to +500
/** The destination account. */
destination: Address;
/** The source account's owner/delegate, or its multisignature account. */
authority: Address | TransactionSigner;
amount: number | bigint;
decimals: number;
/** The signer accounts for a multisignature authority. */
multiSigners?: TransactionSigner[];
/** The token program the mint belongs to. Defaults to Token-2022. */
programAddress?: Address;
};

/**
* Builds a `transferChecked` instruction for `mint`, appending the extra accounts its transfer hook
* program's `Execute` CPI needs when the mint has the transfer hook extension configured.
*
* Fetches the mint to discover whether a transfer hook is set and, if so, its program address, then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper accepts a custom programAddress (defaulting to Token-2022) but unconditionally calls fetchMint from ./generated, which is the Token-2022 mint decoder. Two follow-ups:

  • If a caller passes the classic Token program here with a classic mint, fetchMint will still decode (the base mint layout is identical) but the extensions field will be none() and the helper degrades to a plain transferChecked — which is what you want, but silently. Worth a JSDoc note that the transfer-hook branch is only exercised for Token-2022 mints.
  • Consider whether to drop the programAddress override entirely if the intent is that this helper is Token-2022-only.

Not a blocker, mostly a documentation/scope question.

Comment on lines +316 to +321
* `previousMetas` must contain every account already resolved for this instruction, in order,
* since seed/pubkey-data configs and PDA-owner account-index configs can reference them.
*/
export async function resolveExtraAccountMeta(
extraMeta: ExtraAccountMeta,
previousMetas: Address[],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this branch catches discriminators in the range 3..127 too — none of them have the 1<<7 bit set, so accountIndex comes out negative and hits the bounds check with an "out-of-bounds account index" message. Technically correct but a bit misleading for that range (they're not really account indices at all, just unknown discriminators). Consider either a discriminator < 1<<7 early-throw with a clearer "unknown discriminator" message, or leaving a comment noting the intended range.

Comment thread clients/js/test/transferHookExtraAccountMetas.test.ts
Replace the hand-rolled ExtraAccountMeta parsing with encoder/decoder/codec triples that follow the generated-client convention. The ExtraAccountMetaList struct and its manual slice-to-count step collapse into a single array layout: getArrayDecoder bounds the list by its u32 count prefix and padLeftDecoder skips the 12-byte account prefix. The matching encoder is kept so the upcoming default initialize/update helpers can reuse it.

The decoder wraps the layout in a small createDecoder that asserts the data is at least as long as the 12-byte account prefix before reading, so a malformed validation account fails with a clear error rather than decoding garbage. This folds the old getExtraAccountMetas length check into the codec itself, so that standalone helper is removed in favour of getExtraAccountMetasDecoder().decode(data).
Interpret each ExtraAccountMeta's packed addressConfig eagerly so the decoder yields a fully-usable value. getExtraAccountMetasDecoder now returns metas whose addressConfig is already decoded into an ExtraAccountMetaConfig union (Literal, PubkeyData, ProgramPda, AccountPda), rather than a raw discriminator and byte blob that callers must re-decode.

The seed and pubkey-data configs are modelled as their own discriminated unions (ExtraAccountMetaSeed and ExtraAccountMetaPubkeyData) built from getUnionEncoder/getUnionDecoder, which map the interface's 1-based discriminators to variant indices without modelling the Uninitialized terminator as a variant. The seed list is terminated by the fixed 32-byte address config slot rather than an explicit terminator byte, matching the on-chain packer and allowing a maximal 32-byte seed list to round-trip. The whole ExtraAccountMeta is exposed as an encoder/decoder/codec triple so the upcoming default initialize/update helpers can encode validation accounts from the same definition.
Throw a domain-specific "unknown discriminator" error, naming the actual on-chain discriminator, when decoding an extra account meta's seed or pubkey-data config, rather than surfacing kit's variant-index-based UNION_VARIANT_OUT_OF_RANGE error. This makes a malformed validation account far easier to diagnose, including for discriminators in the unused 3..127 range.
Rename createTransferCheckedWithTransferHookInstruction to getTransferCheckedWithTransferHookInstructionAsync and reshape it to the package's client-first (client, input, config?) convention, taking rpc from the client and the token program override via config.tokenProgram. This mirrors the other composed helpers such as getCreateMintInstructionPlan.

Wire the helper into the token2022 plugin as client.token2022.instructions.transferCheckedWithTransferHook, so callers can build and send a hook-aware checked transfer the same way as the other plugin instructions, and document that the transfer hook branch only applies to Token-2022 mints.
Add getDefaultInitializeExtraAccountMetaListInstructionAsync and getDefaultUpdateExtraAccountMetaListInstructionAsync, hand-written builders for the spl-transfer-hook-interface instructions that create and overwrite a mint's validation account. Their account lists and 8-byte discriminators mirror the interface, and the instruction data reuses the ExtraAccountMeta encoder so the entry layout has a single definition.

These are the interface-standard instructions, useful for authors of simple transfer hooks that follow the plain interface. The JSDoc directs callers to a specific hook program's own helpers when one is provided, since a non-trivial hook often needs program-specific setup beyond the default.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants